/* 
 * ProductExportJob.java
 * Copyright (c) 2008 WayfarerX.net and others (see below).
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * 
 * Contributors:
 *   WayfarerX (2008) - Initial documentation & implementation.
 * 
 */
package net.wayfarerx.tools.export.internal;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.jobs.IJobChangeEvent;
import org.eclipse.core.runtime.jobs.IJobChangeListener;
import org.eclipse.core.runtime.jobs.Job;

/**
 * The job that is triggered after the default product export completes.
 * 
 * @author WayfarerX
 */
final class ProductExportJob extends Job implements IJobChangeListener,
    Runnable {
	/** Either the root directory or archive file that was exported to. */
	private final File exportLocation;
	/** The job instance. */
	private final AtomicReference<Job> instance = new AtomicReference<Job>();

	/**
	 * Creates a new <code>ProductExportJob</code>.
	 * 
	 * @param exportLocation Either the root directory or archive file that was
	 *          exported to.
	 */
	ProductExportJob(File exportLocation) {
		super(Messages.job_name);
		this.exportLocation = exportLocation;
		setUser(true);
		super.getJobManager().addJobChangeListener(this);
	}

	/**
	 * Runs the polisher against the export location.
	 * 
	 * @param monitor The progress monitor to use.
	 * @param polisher The product polisher to run.
	 * @throws Exception If the polishing process fails.
	 */
	private void runPolisher(IProgressMonitor monitor, ProductPolisher polisher)
	    throws Exception {
		File location = exportLocation.getCanonicalFile();
		// Directly process an exported directory.
		if (location.isDirectory()) {
			if (monitor.isCanceled())
				return;
			monitor.beginTask(Messages.job_polishing, 100);
			polisher.polish(monitor, location);
			return;
		}
		// Extract an exported archive to process.
		File tmp = File.createTempFile("wxdn-tools", //$NON-NLS-1$
		    "export").getCanonicalFile(); //$NON-NLS-1$
		delete(tmp);
		try {
			tmp.mkdirs();
			// Extract the exported product for the polisher.
			if (monitor.isCanceled())
				return;
			monitor.beginTask(Messages.job_preparing, 300);
			extract(monitor, location, tmp);
			// Find the root directory and run the polisher.
			if (monitor.isCanceled())
				return;
			monitor.setTaskName(Messages.job_polishing);
			LinkedHashSet<String> children = new LinkedHashSet<String>();
			children.addAll(Arrays.asList(tmp.list()));
			children.remove("."); //$NON-NLS-1$
			children.remove(".."); //$NON-NLS-1$
			if (children.size() != 1)
				throw new IllegalStateException(String.valueOf(children));
			polisher.polish(monitor, new File(tmp, children.iterator().next())
			    .getCanonicalFile());
			// Archive the polished version of the product.
			if (monitor.isCanceled())
				return;
			monitor.setTaskName(Messages.job_archiving);
			File archive =
			    new File(location.getParentFile(), "tmp-" + location.getName()) //$NON-NLS-1$
			        .getCanonicalFile();
			try {
				archive(monitor, tmp, archive);
				delete(location);
				archive.renameTo(location);
			} finally {
				delete(archive);
			}
		} finally {
			delete(tmp);
		}
	}

	/**
	 * Extracts the contents of a product archive to a temporary directory.
	 * 
	 * @param monitor The progress monitor to use.
	 * @param archive The archive to extract.
	 * @param directory The directory to extract the archive to.
	 * @throws IOException If the extract operation fails.
	 */
	private void extract(IProgressMonitor monitor, File archive, File directory)
	    throws IOException {
		ZipFile zip = new ZipFile(archive);
		try {
			Map<String, File> map = new LinkedHashMap<String, File>();
			for (Enumeration<? extends ZipEntry> e = zip.entries(); e
			    .hasMoreElements();) {
				if (monitor.isCanceled())
					return;
				ZipEntry entry = e.nextElement();
				map.put(entry.getName(), new File(directory, entry.getName())
				    .getCanonicalFile());
			}
			double step = 100d / (double) (map.size() + 1);
			double percent = step;
			int done = (int) Math.round(percent);
			if (done > 0)
				monitor.worked(done);
			byte[] buffer = new byte[1024 * 10];
			for (Map.Entry<String, File> entry : map.entrySet()) {
				if (monitor.isCanceled())
					return;
				if (entry.getKey().endsWith("/")) //$NON-NLS-1$
					entry.getValue().mkdirs();
				else {
					entry.getValue().getParentFile().mkdirs();
					InputStream input = zip.getInputStream(zip.getEntry(entry.getKey()));
					try {
						OutputStream output = new FileOutputStream(entry.getValue());
						try {
							for (int i = input.read(buffer); i >= 0; i = input.read(buffer)) {
								if (monitor.isCanceled())
									return;
								output.write(buffer, 0, i);
							}
						} finally {
							output.close();
						}
					} finally {
						input.close();
					}
				}
				percent += step;
				int newDone = (int) Math.round(percent);
				if (newDone > done) {
					monitor.worked(newDone - done);
					done = newDone;
					Thread.yield();
				}
			}
		} finally {
			zip.close();
		}
	}

	/**
	 * Archives the contents of a temporary directory in a product archive.
	 * 
	 * @param monitor The progress monitor to use.
	 * @param directory The directory to create the archive from.
	 * @param archive The archive to create.
	 * @throws IOException If the archive operation fails.
	 */
	private void archive(IProgressMonitor monitor, File directory, File archive)
	    throws IOException {
		Map<String, File> map = new LinkedHashMap<String, File>();
		String base = directory.getAbsolutePath().replace('\\', '/');
		if (!base.endsWith("/")) //$NON-NLS-1$
			base += "/"; //$NON-NLS-1$
		LinkedList<File> queue = new LinkedList<File>();
		LinkedHashSet<String> children = new LinkedHashSet<String>();
		children.addAll(Arrays.asList(directory.list()));
		children.remove("."); //$NON-NLS-1$
		children.remove(".."); //$NON-NLS-1$
		for (String child : children)
			queue.addLast(new File(directory, child).getCanonicalFile());
		children.clear();
		while (!queue.isEmpty()) {
			if (monitor.isCanceled())
				return;
			File next = queue.removeFirst().getCanonicalFile();
			String path = next.getAbsolutePath().substring(base.length());
			if (!next.isDirectory()) {
				map.put(path, next);
				continue;
			}
			path += "/"; //$NON-NLS-1$
			map.put(path, next);
			String[] list = next.list();
			for (int i = list.length - 1; i >= 0; --i)
				children.add(list[i]);
			children.remove("."); //$NON-NLS-1$
			children.remove(".."); //$NON-NLS-1$
			for (String child : children)
				queue.addFirst(new File(next, child).getCanonicalFile());
			children.clear();
		}
		double step = 100d / (double) (map.size() + 1);
		double percent = step;
		int done = (int) Math.round(percent);
		if (done > 0)
			monitor.worked(done);
		byte[] buffer = new byte[1024 * 10];
		ZipOutputStream output = new ZipOutputStream(new FileOutputStream(archive));
		try {
			for (Map.Entry<String, File> entry : map.entrySet()) {
				if (monitor.isCanceled())
					return;
				ZipEntry zipEntry = new ZipEntry(entry.getKey());
				zipEntry.setTime(System.currentTimeMillis());
				if (entry.getValue().isFile()) {
					zipEntry.setSize(entry.getValue().length());
					output.putNextEntry(zipEntry);
					FileInputStream input = new FileInputStream(entry.getValue());
					try {
						for (int i = input.read(buffer); i >= 0; i = input.read(buffer)) {
							if (monitor.isCanceled())
								return;
							output.write(buffer, 0, i);
						}
					} finally {
						input.close();
					}
					output.closeEntry();
				} else
					output.putNextEntry(zipEntry);
				percent += step;
				int newDone = (int) Math.round(percent);
				if (newDone > done) {
					monitor.worked(newDone - done);
					done = newDone;
					Thread.yield();
				}
			}
		} finally {
			output.close();
		}
	}

	/**
	 * Deletes the file system tree pointed at by the specified file.
	 * 
	 * @param file The root of the tree to delete.
	 * @return True if the delete was successful.
	 * @throws IOException If the delete operation fails.
	 */
	private boolean delete(File file) throws IOException {
		File root = file.getCanonicalFile();
		if (!root.isDirectory())
			return root.delete();
		LinkedList<File> queue = new LinkedList<File>();
		LinkedHashSet<String> children = new LinkedHashSet<String>();
		queue.add(root);
		while (!queue.isEmpty()) {
			File next = queue.removeFirst();
			if (!next.isDirectory()) {
				if (!next.delete())
					return false;
				continue;
			}
			children.addAll(Arrays.asList(next.list()));
			children.remove("."); //$NON-NLS-1$
			children.remove(".."); //$NON-NLS-1$
			if (children.isEmpty()) {
				if (!next.delete())
					return false;
				continue;
			}
			for (String child : children)
				queue.addLast(new File(next, child).getCanonicalFile());
			queue.addLast(next);
			children.clear();
		}
		return true;
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.eclipse.core.runtime.jobs.Job#run(
	 *      org.eclipse.core.runtime.IProgressMonitor)
	 */
	@Override
	protected IStatus run(IProgressMonitor monitor) {
		try {
			ProductPolisher polisher = ProductPolisher.newInstance();
			if (polisher != null)
				runPolisher(monitor, polisher);
		} catch (Exception e) {
			return new Status(IStatus.ERROR, Activator.getSymbolicName(),
			    Messages.job_failed, e);
		} finally {
			monitor.done();
		}
		return new Status(IStatus.OK, Activator.getSymbolicName(),
		    Messages.job_completed);
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.eclipse.core.runtime.jobs.IJobChangeListener#scheduled(
	 *      org.eclipse.core.runtime.jobs.IJobChangeEvent)
	 */
	@SuppressWarnings("restriction")//$NON-NLS-1$
	public void scheduled(IJobChangeEvent event) {
		if (!(event.getJob() instanceof org.eclipse.pde.internal.ui.build.ProductExportJob))
			return;
		instance.compareAndSet(null, event.getJob());
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.eclipse.core.runtime.jobs.IJobChangeListener#aboutToRun(
	 *      org.eclipse.core.runtime.jobs.IJobChangeEvent)
	 */
	public void aboutToRun(IJobChangeEvent event) {}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.eclipse.core.runtime.jobs.IJobChangeListener#running(
	 *      org.eclipse.core.runtime.jobs.IJobChangeEvent)
	 */
	public void running(IJobChangeEvent event) {}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.eclipse.core.runtime.jobs.IJobChangeListener#sleeping(
	 *      org.eclipse.core.runtime.jobs.IJobChangeEvent)
	 */
	public void sleeping(IJobChangeEvent event) {}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.eclipse.core.runtime.jobs.IJobChangeListener#awake(
	 *      org.eclipse.core.runtime.jobs.IJobChangeEvent)
	 */
	public void awake(IJobChangeEvent event) {}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.eclipse.core.runtime.jobs.IJobChangeListener#done(
	 *      org.eclipse.core.runtime.jobs.IJobChangeEvent)
	 */
	public void done(IJobChangeEvent event) {
		if (!instance.compareAndSet(event.getJob(), null))
			return;
		getJobManager().removeJobChangeListener(this);
		if (event.getResult().isOK())
			new Thread(this).start();
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see java.lang.Runnable#run()
	 */
	public void run() {
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {}
		schedule();
	}
}
